Skip to content

feat: Async payment migration with transactional safety, REST API, and comprehensive tests#180

Open
devin-ai-integration[bot] wants to merge 2 commits intoDevOpsfrom
devin/1777458309-async-payment-migration
Open

feat: Async payment migration with transactional safety, REST API, and comprehensive tests#180
devin-ai-integration[bot] wants to merge 2 commits intoDevOpsfrom
devin/1777458309-async-payment-migration

Conversation

@devin-ai-integration
Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot commented Apr 29, 2026

Summary

Migrates synchronous payment flows (deposit, withdraw, transfer) to use async patterns, adds transactional safety, introduces a REST API layer, and generates comprehensive tests for all critical payment flows.

Key changes:

  • @Transactional on deposit(), withdraw(), transferAmount() with fresh entity reload inside transaction boundary to prevent stale reads/double-spending
  • TransactionLoggingService with @Async("paymentTaskExecutor") for non-blocking audit trail
  • BankRestController REST API at /api/** with async endpoints
  • TransactionStatus enum (PENDING, COMPLETED, FAILED)
  • 40 tests across 6 test classes (unit, integration, async, transactional integrity)
  • docs/LEARNINGS.md documenting Spring async/transactional patterns

Review & Testing Checklist for Human

  • Verify the entity reload pattern in AccountService.deposit(), withdraw(), and transferAmount() — each method calls accountRepository.findById() at the start to get a fresh entity within the transaction boundary. Confirm this prevents the stale-entity race condition described in the review comments
  • Test concurrent transfers via the REST API (POST /api/transfer) to verify no double-spending occurs under load — the findById() reload mitigates but does not use pessimistic/optimistic locking, so extreme concurrency may still need @Version on Account
  • Verify async transaction logging doesn't silently fail in production — the TransactionLoggingService logs errors but doesn't retry. Check if missing audit records are acceptable or if a retry/dead-letter mechanism is needed
  • Test the REST API endpoints with valid authentication to confirm JSON responses match expected format

Notes

  • The Transaction entity table was renamed to bank_transaction to avoid SQL reserved keyword conflicts with H2 (and for safety with other databases)
  • SecurityContextHolder doesn't propagate to async threads — username is captured synchronously before CompletableFuture.supplyAsync()
  • Async logging errors (referential integrity violations in test logs) are expected during test teardown when accounts are cleaned up before async threads complete — these don't affect test results

Link to Devin session: https://app.devin.ai/sessions/625f881cdd794bfb84c645333588e44b
Requested by: @joao-cognition

…sive tests

Phase 1: Added @transactional to deposit(), withdraw(), and transferAmount()
Phase 2: Async migration with AsyncConfig, TransactionLoggingService,
         BankRestController (REST API), TransactionStatus enum
Phase 3: 40 tests (unit + integration + async + transactional integrity)
Phase 4: LEARNINGS.md documenting @async, @transactional, CompletableFuture,
         race conditions, and SecurityContext thread boundaries

Co-Authored-By: Joao Esteves <joao.esteves@cognition.ai>
@devin-ai-integration
Copy link
Copy Markdown
Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@staging-devin-ai-integration
Copy link
Copy Markdown

Devin Review

Status Commit
⚪ Not started

Open in Devin Review (Staging)

💡 Connect your GitHub account to enable automatic code reviews.

Copy link
Copy Markdown
Author

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines 110 to 118
toAccount.setBalance(toAccount.getBalance().add(amount));
accountRepository.save(toAccount);

// Create transaction records for both accounts
Transaction debitTransaction = new Transaction(
amount,
"Transfer Out to " + toAccount.getUsername(),
LocalDateTime.now(),
fromAccount
);
transactionRepository.save(debitTransaction);

Transaction creditTransaction = new Transaction(
amount,
"Transfer In from " + fromAccount.getUsername(),
LocalDateTime.now(),
toAccount
);
transactionRepository.save(creditTransaction);
// Delegate transaction logging to async service
transactionLoggingService.logTransferAsync(
fromAccount, amount, "Transfer Out to " + toAccount.getUsername());
transactionLoggingService.logTransferAsync(
toAccount, amount, "Transfer In from " + fromAccount.getUsername());
}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Stale entity read outside @transactional boundary enables lost updates and double-spending

In BankRestController, the Account entity is loaded via accountService.findAccountByUsername(username) (which is NOT @Transactional) BEFORE the @Transactional service methods (deposit(), withdraw(), transferAmount()) are called. The balance check and balance mutation inside those methods use the in-memory balance from this stale, detached entity.

For transferAmount() specifically (AccountService.java:97-118): fromAccount is passed in from the controller with a potentially stale balance. Two concurrent transfers can both read $1000, both pass fromAccount.getBalance().compareTo(amount) < 0, both subtract, and when accountRepository.save(fromAccount) calls merge(), the stale balance overwrites any concurrent committed changes. This allows transferring more money than available (double-spending).

Concrete race scenario

Thread A reads balance=$1000, Thread B reads balance=$1000.
Thread A enters transferAmount, subtracts $700 → saves $300, commits.
Thread B enters transferAmount, checks stale $1000 >= $700 ✓, subtracts $700 → merge overwrites to $300, commits.
Result: $1400 transferred out but only $700 deducted. Money was created from nothing.

The @Transactional provides atomicity (both account saves succeed or fail together) but does NOT prevent lost updates because merge() copies the detached entity's stale field values over the freshly-loaded managed entity. Without @Version (optimistic locking) or loading the entity inside the transaction, this race condition remains exploitable.

(Refers to lines 96-118)

Prompt for agents
The root cause is that Account entities are loaded outside the @Transactional boundary in both BankController and BankRestController, then passed into @Transactional service methods. The service methods mutate the stale in-memory balance and merge it back, overwriting any concurrent committed changes.

To fix this, the @Transactional service methods should re-load the Account entity from the database inside the transaction, rather than trusting the stale entity passed as a parameter. For example, in transferAmount(), instead of using fromAccount.getBalance() directly, reload it: Account freshFrom = accountRepository.findById(fromAccount.getId()).orElseThrow(...); then operate on freshFrom. Alternatively, add @Version to the Account entity for optimistic locking, which would cause concurrent merges to throw OptimisticLockException. The same fix applies to deposit() and withdraw() in AccountService.java.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1216a1e. All three @Transactional methods (deposit, withdraw, transferAmount) now reload the Account from the database via accountRepository.findById() at the start of the transaction, ensuring the balance check and mutation operate on the current committed state rather than a stale detached entity passed from the controller.

Comment on lines +37 to +47
return CompletableFuture.supplyAsync(() -> {
Account account = accountService.findAccountByUsername(username);
accountService.deposit(account, amount);

Account updated = accountService.findAccountByUsername(username);
Map<String, Object> response = new HashMap<>();
response.put("message", "Deposit successful");
response.put("newBalance", updated.getBalance());
response.put("amount", amount);
return ResponseEntity.ok(response);
}, paymentTaskExecutor);
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Deposit REST endpoint missing exception handling unlike withdraw and transfer

The deposit() endpoint in BankRestController does not wrap the service call in a try-catch block, unlike withdraw() (line 56-61) and transfer() (line 80-86) which catch RuntimeException and return a proper 400 response with an error message. If accountService.deposit() throws any RuntimeException (e.g., a database constraint violation, or a negative amount causing downstream issues), the CompletableFuture completes exceptionally and Spring returns an opaque 500 error instead of a structured error response.

Suggested change
return CompletableFuture.supplyAsync(() -> {
Account account = accountService.findAccountByUsername(username);
accountService.deposit(account, amount);
Account updated = accountService.findAccountByUsername(username);
Map<String, Object> response = new HashMap<>();
response.put("message", "Deposit successful");
response.put("newBalance", updated.getBalance());
response.put("amount", amount);
return ResponseEntity.ok(response);
}, paymentTaskExecutor);
return CompletableFuture.supplyAsync(() -> {
Account account = accountService.findAccountByUsername(username);
try {
accountService.deposit(account, amount);
} catch (RuntimeException e) {
Map<String, Object> errorResponse = new HashMap<>();
errorResponse.put("error", e.getMessage());
return ResponseEntity.badRequest().body(errorResponse);
}
Account updated = accountService.findAccountByUsername(username);
Map<String, Object> response = new HashMap<>();
response.put("message", "Deposit successful");
response.put("newBalance", updated.getBalance());
response.put("amount", amount);
return ResponseEntity.ok(response);
}, paymentTaskExecutor);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1216a1e. Added try-catch around accountService.deposit() matching the pattern used in withdraw() and transfer() endpoints.

…pdates

- Reload Account from DB via findById() inside deposit(), withdraw(),
  and transferAmount() to prevent stale entity reads and double-spending
- Add try-catch error handling to deposit REST endpoint for consistency
  with withdraw and transfer endpoints
- Update unit tests to mock findById() calls

Co-Authored-By: Joao Esteves <joao.esteves@cognition.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants